Skip to content

fix(log_lib): run_with_log hangs forever when an orphan holds the child's pipe - #7

Open
tigist-far wants to merge 1 commit into
farai/mainfrom
tigist/run-with-log-hangs-when-orphans-hold-the-pipe
Open

tigist-far wants to merge 1 commit into
farai/mainfrom
tigist/run-with-log-hangs-when-orphans-hold-the-pipe

Conversation

@tigist-far

@tigist-far tigist-far commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

run_with_log reads the child's output until EOF and only then calls proc.wait(), on the assumption stated in the code that "Stream processing already waited for process completion". EOF needs every write end of the pipe closed, including the ones a grandchild inherited, so whenever a grandchild outlives the child the reader blocks forever, proc.wait() is never reached, and the function never returns a return code. There is no timeout on that path.

Fix. A watchdog thread waits for the child, gives the readers a grace period (30 s, _ORPHANED_PIPE_GRACE_SECONDS) to drain, and only if they are still blocked after it kills the child's process group (os.killpg(pgid, SIGKILL)) to force EOF.

sequenceDiagram
    participant C as child (start_new_session)
    participant G as orphaned grandchildren
    participant R as run_with_log readers
    participant W as watchdog thread
    C->>G: spawn (inherit stdout/stderr pipe)
    C-->>W: exits (rc known)
    Note over G: reparented to init, pipe still open
    Note over R: blocked in read: no EOF
    W->>W: wait grace period for the readers to drain
    W->>G: killpg(child pid, SIGKILL)
    Note over R: EOF -> proc.wait() -> return rc
Loading

Why it matters

Observed in production on a two-node training job. The ranks aborted on an RDMA transport error and torchrun exited, but five orphaned multiprocessing.spawn children kept the stdout pipe open:

5 × spawn_main   PPid=1   fd1,fd2 -> pipe:[2925536000]
ray::head        fd35    -> pipe:[2925536000]   wchan=pipe_read

The Ray task running the command never finished, so the managed-jobs controller reported JobStatus.RUNNING for two hours (#RECOVERIES 0) while 16 GPUs sat idle. Autorecovery cannot fire on a job that never reports failure.

Design points

Decision Reason
Kill the process group, not the tree kill_children_processes walks the process tree, and orphans are reparented to init, so they have left it. start_new_session=True makes the child a session and group leader, so its group id is its pid, and neither reparenting nor reaping the leader moves the orphans out of the group
The group id is proc.pid, not os.getpgid(proc.pid) A short-lived child is already a zombie when the watchdog starts and getpgid on it can fail; a first attempt did that and degraded silently to a no-op
The kill fires only when the child has exited and the readers are still blocked after the grace period An unconditional group-kill after exit would break commands that deliberately leave daemons in the group (ray start among them). When the narrow condition holds the caller was already hung forever, so forcing EOF cannot lose anything still working

Areas changed

Area Files What changed
sky/skylet/log_lib.py 1 (+67) _force_eof_when_orphans_hold_the_pipe watchdog thread started by run_with_log after the child is spawned; _ORPHANED_PIPE_GRACE_SECONDS = 30; the readers signal a threading.Event when drained
tests/unit_tests/test_sky/skylet/test_log_lib.py 1 (+42) A child that exits while a background grandchild holds its stdout: with the fix run_with_log returns the child's exit code after the grace period; without it the test hangs (killed at 90 s)

Testing

tests/unit_tests/test_sky/skylet/test_log_lib.py: 13 passed in 4.9 s (the new test included). yapf (pinned 0.32.0), isort and the whitespace hooks are clean. The fork has no GitHub Actions on pull requests.

…ld's pipe

`process_subprocess_stream` reads the child's output until EOF, and only
then does `run_with_log` call `proc.wait()` -- with the comment "Stream
processing already waited for process completion". That assumption breaks
whenever a grandchild outlives the child: EOF needs EVERY write end of the
pipe closed, including the ones the grandchild inherited, so the reader
stays blocked, `proc.wait()` is never reached, and `run_with_log` never
returns a returncode. The caller hangs with no timeout.

Seen in production on a two-node training job: the ranks aborted on an RDMA
transport error, torchrun exited, but five orphaned multiprocessing-spawn
children kept the stdout pipe open. The Ray task running the command never
finished, so the managed-jobs controller reported the job as RUNNING for two
hours while 16 GPUs sat idle.

`kill_children_processes` cannot help: it walks the process tree, and the
orphans are reparented to init, so they are no longer in it. The process
group still reaches them -- `start_new_session=True` makes the child a group
leader, so the group id is its pid, and neither reparenting nor reaping the
leader moves the orphans out of that group.

A watchdog thread now waits for the child, then waits a grace period for the
readers to drain, and only if they are still blocked kills the leftover
process group to force EOF. That trigger matters: an unconditional kill after
exit would break commands that deliberately leave daemons in the group, such
as `ray start`. When it does fire, the caller was already hung forever, so
forcing EOF cannot lose anything that was still working.

Test: a child that exits while a background grandchild holds its stdout.
Without the fix it hangs (killed at 90 s); with it, `run_with_log` returns
the child's exit code after the grace period.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@levfarai levfarai left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reasonable, though this means it can't be used as a launchpad for other things that are meant to run async.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants